diff --git a/apps/website/content/en/1.docs/3.upstream/2.request-summary.md b/apps/website/content/en/1.docs/3.upstream/2.request-summary.md index 0962dac2..e8e4f047 100644 --- a/apps/website/content/en/1.docs/3.upstream/2.request-summary.md +++ b/apps/website/content/en/1.docs/3.upstream/2.request-summary.md @@ -3,7 +3,7 @@ title: Request summary and priority ref: docs/upstream/request-summary description: All twelve upstream requests in one table, with priority, estimated effort, blast radius, and the host workaround that each fix removes. icon: lucide:list-checks -updated: 2026-08-08 +updated: 2026-08-13 navigation: title: Request summary --- @@ -72,7 +72,7 @@ Read them as a rough size, not a commitment. :::card{title="Input" description="Three defects in mouse, cursor, and keyboard handling." icon="lucide:mouse-pointer-click" to="$docs/upstream/input/double-click-flag"} `UR-07` to `UR-09` ::: -:::card{title="Runtime" description="Sessions can stop at the 2 GiB cap. A paired 4 GB build buys time; a heap profile must classify retention, fragmentation, or live ownership." icon="lucide:cpu" to="$docs/upstream/runtime/heap-maximum"} +:::card{title="Runtime" description="Sessions can stop at the 2 GiB cap. Our post-build 4 GB experiment was withdrawn; a heap profile must classify retention, fragmentation, or live ownership." icon="lucide:cpu" to="$docs/upstream/runtime/heap-maximum"} `UR-10` ::: :::card{title="Interface proposals" description="Two proposals. They do not correct a defect." icon="lucide:plug" to="$docs/upstream/proposals/build-identification"} diff --git a/apps/website/content/en/1.docs/3.upstream/5.runtime/1.heap-maximum.md b/apps/website/content/en/1.docs/3.upstream/5.runtime/1.heap-maximum.md index 94c7b622..de5fd717 100644 --- a/apps/website/content/en/1.docs/3.upstream/5.runtime/1.heap-maximum.md +++ b/apps/website/content/en/1.docs/3.upstream/5.runtime/1.heap-maximum.md @@ -3,11 +3,19 @@ title: "UR-10: long sessions stop at the 2 GiB heap cap" ref: docs/upstream/runtime/heap-maximum description: Build 38797 has a fixed 2 GiB WebAssembly heap. We can prove the ceiling and the failed allocation, but cache retention, fragmentation, and live working set still require an ArenaNet heap profile. icon: lucide:cpu -updated: 2026-08-08 +updated: 2026-08-13 navigation: title: Heap maximum (UR-10) --- +::warning{title="The experimental 4 GB profile has been withdrawn"} +Two users reported severe graphical corruption after 30–45 minutes with the +post-build 4 GB transform enabled. GWonMac has removed the option and restores +ArenaNet's standard 2 GiB contract on startup. The reports are strong safety +evidence, not proof of the exact corrupt pointer path. A compiler-supported +ArenaNet rebuild or a source-level cache-lifecycle fix remains required. +:: + Some content-heavy sessions end when the WebAssembly heap reaches exactly 2,147,483,648 bytes. That maximum is declared in both artifacts ArenaNet ships: the WASM memory section and its generated JavaScript glue. The next allocation @@ -171,9 +179,9 @@ GWonMac's production warning monitors the effective heap capacity while there is still room to return to a town or outpost, then lets the player choose when to reload. Reloading resets the WASM heap. It is recovery, not a memory fix. -### Exact-build 4 GB research profile +### Withdrawn exact-build 4 GB research profile -We have also built an off-by-default research profile for build 38797. It +We built an off-by-default research profile for build 38797. It changes the WASM maximum to 65,535 pages—4 GiB minus one 64 KiB page—and transforms the exact generated JavaScript to treat high wasm32 pointers as unsigned addresses. @@ -191,17 +199,12 @@ Qualification completed so far: - the transformed generated string glue wrote and read `GW4G` through that high pointer without trapping. -This proves the core approach across the former boundary. It does not prove -every rare WebGL or host callback path, and it does not bound the underlying -memory demand. The current profile remains a developer experiment. All five -variants the production chain can emit—including Enhancements disabled—are -hash-pinned. It still needs packaged high-address gameplay coverage, -system-memory testing, and an explicit user setting before an experimental -player release. - -If released before an ArenaNet update, it will be labelled **Experimental 4 GB -memory mode**, disabled by default, restricted to exact certified builds, easy -to turn off, and recommended only on Macs with at least 16 GB of RAM. +Those tests proved selected allocator and JavaScript glue paths across the +former boundary. They did not prove every WebGL or host callback path. After +release, two users reported severe graphical corruption after 30–45 minutes +with the option enabled. The profile is therefore withdrawn and its executable +transform and certificates have been deleted. Git history preserves the +research; it is not available through a hidden switch. ## What we will not do @@ -226,15 +229,13 @@ For ArenaNet's corrective build: work above 2 GiB; - the client retains a clear failure reason if it exhausts the new maximum. -For our optional mitigation, every accepted JS/WASM pair must be pinned to an -exact ArenaNet build, unknown builds must remain untouched, the mode must be -visible in diagnostics, and disabling it must restore ordinary client behavior -after restart. +Historical archives containing the experiment remain readable and are labelled +as legacy captures. ## Current conclusion The 2 GiB limit and final failed allocation are in ArenaNet's shipped client. The reason some sessions consume the available address space still needs an -ArenaNet source-level heap profile. Until that is fixed, a safe reload remains -the conservative mitigation and an exact-build 4 GB mode can provide optional -headroom for informed testers. +ArenaNet source-level heap profile. Until that is fixed, the warning and +user-controlled safe reload remain the only client-side mitigation we consider +safe. diff --git a/apps/website/content/en/1.docs/3.upstream/7.reference/1.our-workarounds.md b/apps/website/content/en/1.docs/3.upstream/7.reference/1.our-workarounds.md index 0396a203..b253cff6 100644 --- a/apps/website/content/en/1.docs/3.upstream/7.reference/1.our-workarounds.md +++ b/apps/website/content/en/1.docs/3.upstream/7.reference/1.our-workarounds.md @@ -3,14 +3,15 @@ title: Our workarounds, and what each fix removes ref: docs/upstream/reference/our-workarounds description: What GWonMac does to the client today, why each workaround exists, what it costs us per client build, and exactly what we delete after each upstream fix. icon: lucide:wrench -updated: 2026-08-08 +updated: 2026-08-13 navigation: title: Our workarounds --- This page is everything our host does to the client today: why each workaround exists, what it costs per client build, and exactly what each upstream fix -lets us delete. +removes. The withdrawn memory experiment remains below as an explicitly marked +historical record. ## Ground rules @@ -126,21 +127,20 @@ memory capacity cannot shrink inside one process; reload starts a new heap. ownership policy is bounded and long content-diverse sessions remain well below the effective cap. The general crash-recovery path remains. -## Research profile — paired 4 GB JS/WASM transform +## Withdrawn research profile — paired 4 GB JS/WASM transform **Reduced or removed by:** `UR-10`. -An off-by-default developer profile raises build 38797's WASM maximum to 4 GiB +An off-by-default developer profile raised build 38797's WASM maximum to 4 GiB minus one page and transforms the matching generated JavaScript to normalize wasm32 pointers above 2 GiB. It is a paired transform: changing only the WASM maximum would hand negative pointer values to JavaScript and is refused. -The exact pair has passed ArenaNet-allocator qualification above 3 GiB and a -live Electron string-glue test at 2,625 MiB. It remains a research profile, not -a default player setting. All five variants the production chain can emit are -hash-pinned. Before an experimental release it still needs packaged -high-address gameplay coverage, system-memory testing, and explicit opt-in -copy. Diagnostics record the effective mode and cap. +The exact pair passed allocator qualification above 3 GiB and a live Electron +string-glue test at 2,625 MiB. Those bounded tests were not enough: two users +later reported severe graphical corruption after 30–45 minutes with the option +enabled. We withdrew and deleted the executable profile. This is strong safety +evidence, not proof of the exact corrupt pointer path. **Cost per client build.** Both official artifact hashes, every accepted predecessor hash, both derived hashes, and the generated-glue pointer audit. @@ -165,5 +165,5 @@ headroom. Six of the twelve requests exist because of file handling; one exists because of a single unwritten byte. Fix `UR-01` to `UR-07` and three production workarounds disappear outright. A source-level `UR-10` correction also avoids -turning the research-only 4 GB profile into another permanent certification -burden. +depending on another post-build 4 GB transform. A compiler-supported ArenaNet +rebuild or cache-lifecycle fix remains required. diff --git a/apps/website/content/en/2.blog/4.gwonmac-tools-is-here.md b/apps/website/content/en/2.blog/4.gwonmac-tools-is-here.md index 86a24367..75be3143 100644 --- a/apps/website/content/en/2.blog/4.gwonmac-tools-is-here.md +++ b/apps/website/content/en/2.blog/4.gwonmac-tools-is-here.md @@ -7,6 +7,12 @@ readingTime: 5 min read author: authors/matamo --- +::warning{title="Update: experimental 4 GB option withdrawn"} +We removed this option after two reports of severe graphical corruption during +30–45 minute sessions. GWonMac now restores ArenaNet's standard 2 GB limit on +startup. The memory warning and Reload Guild Wars recovery remain available. +:: + Release `2026.8.5` introduces **GWonMac Tools Beta**: a local build and team manager made specifically for Guild Wars on Mac. You can capture your current party, create player and hero builds, reorder heroes and skills, share complete teams, and apply a saved setup from a supported PvE outpost. The release also adds template import and export, an experimental 4 GB memory limit, and two interface styles. @@ -65,17 +71,17 @@ You can also publish a build from Tools into Guild Wars. The Tools library and t Read the [template migration guide](/docs/guides/templates) for the complete workflow. -## More memory headroom for long sessions +## Historical: experimental memory headroom Guild Wars normally has a 2 GB memory limit. Very long sessions can eventually approach it and may need a reload. -You can now opt into an experimental 4 GB limit under: +This release originally offered an experimental 4 GB limit under: **Settings → Advanced → Experimental 4 GB memory limit** -The option is off by default and requires restarting Guild Wars. Settings shows whether the current session is using 2 GB or 4 GB. - -The larger limit provides more headroom and may help during long sessions. It does **not** stop memory from continuing to grow. If 4 GB mode is unavailable for the current Guild Wars version, GWonMac safely starts in normal 2 GB mode instead. +The option has since been withdrawn because it can cause severe graphical +corruption during long sessions. Current versions remove existing opt-ins and +always use ArenaNet's original 2 GB JS/WASM pair. The new memory warning stays out of the way and lets you reload Guild Wars directly when the current session is running low. @@ -119,7 +125,7 @@ The most useful feedback now is: - Repeated Team Apply runs with different hero professions and skill bars - Normal and Hard Mode application - Larger or unusual hero rosters -- Long sessions using the experimental 4 GB limit +- Long sessions using the standard memory warning and Reload Guild Wars recovery - Interface and layout behavior on smaller Mac displays If something fails, tell us what you expected, what Guild Wars showed afterward, and which Mac you use. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4e6c2d4c..5e769cc5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -170,9 +170,9 @@ A failed growth request at the compiled limit proves that the official client requested more memory than that module permits. It does not identify which client subsystem retained the earlier allocations. -Function indices apply only to the exact captured module. The 4 GB option -records requested and effective state. An unavailable certified profile falls -back to the ordinary module with a closed reason. +Function indices apply only to the exact captured module. Older archives may +contain the withdrawn 4 GB profile's requested and effective state; current +versions decode that legacy evidence but never emit or activate the profile. ## Performance measurement rule diff --git a/docs/user-guide.md b/docs/user-guide.md index f08e1729..ab04a93c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -150,14 +150,12 @@ Password fields are never copied. The official web client cannot copy text that it only displays, such as chat history or item names. -## Extended memory +## Memory recovery -**Advanced → Experimental 4 GB memory limit** requests the certified 4 GB module -for the next start. Restart `gwonmac` after changing it. - -If the current ArenaNet build has no 4 GB certificate, the app uses the ordinary -2 GB module. The larger limit can delay a memory-related crash. It cannot stop -memory that continues to grow. +Guild Wars uses ArenaNet's standard 2 GB WebAssembly memory limit. A previously +released experimental 4 GB option was withdrawn after reports of severe +graphical corruption during long sessions. Existing opt-ins are removed when +GWonMac next starts. When the app warns about memory, choose **Reload Guild Wars**. Guild Wars normally reconnects. Reload in an outpost when you want the lowest gameplay diff --git a/internal/upstream/memory-exhaustion-log.md b/internal/upstream/memory-exhaustion-log.md index 1db452bb..dbc05fd8 100644 --- a/internal/upstream/memory-exhaustion-log.md +++ b/internal/upstream/memory-exhaustion-log.md @@ -1,6 +1,7 @@ # Long-session WebAssembly memory exhaustion -> **Status: cause confirmed; upstream growth cause unresolved.** This is a +> **Status: cause confirmed; upstream growth cause unresolved; 4 GB host +> mitigation withdrawn.** This is a > historical investigation record. Current diagnostics behavior is defined by > [Diagnostics](../../docs/diagnostics.md). @@ -14,6 +15,13 @@ Increasing `MAXIMUM_MEMORY` to 4 GB would buy about 2.5 times the usable memory on the measured client. It would not fix the continuing growth. The evidence does not distinguish a leak, fragmentation, or retained content. +GWonMac briefly shipped an exact-build post-build 4 GB transform. It was +withdrawn after two users reported severe graphical corruption after 30–45 +minutes with the option enabled. That is strong evidence that the transform is +not safe to ship, although it does not prove the exact corrupt pointer path. A +compiler-supported ArenaNet rebuild or a source-level cache-lifecycle fix is +still required. + gwonmac records heap growth and warns before likely exhaustion. It does not show a minutes-remaining estimate to players because real workloads made that number misleading. diff --git a/package.json b/package.json index 7fa7bfde..8f93ad4b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "diagnostics:compare": "tsc && node build/tools/diagnostics/compare.js", "diagnostics:attribute-stalls": "tsc && node build/tools/diagnostics/attribute-stalls.js", "diagnostics:attribute-frames": "tsc && node build/tools/diagnostics/attribute-frames.js", - "memory:qualify:4gb": "node --import ./scripts/ts-hook.mjs --experimental-strip-types scripts/qualify-extended-memory.ts", "verify:runtime": "pnpm check && pnpm build && node scripts/verify-companion-kernel.mjs && pnpm test:integration && pnpm test:release && pnpm tools:test:e2e && pnpm test:electron", "verify": "pnpm verify:runtime && pnpm package:built && pnpm test:packaged" }, diff --git a/scripts/qualify-extended-memory.ts b/scripts/qualify-extended-memory.ts deleted file mode 100644 index f513389b..00000000 --- a/scripts/qualify-extended-memory.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** Offline proof that build 38797's complete production chain crosses 2 GiB. */ -import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - EXTENDED_MEMORY_JS_BUILD, - EXTENDED_MEMORY_MAX_BYTES, - EXTENDED_MEMORY_WASM_BUILDS, - prepareExtendedMemoryArtifacts, - rewriteExtendedMemoryJs, - rewriteExtendedMemoryWasm, -} from "../src/main/certification/extended-memory.js"; -import { - findTemplateSaveBuild, - rewriteTemplateSaveWasm, -} from "../src/main/certification/template-save-compat.js"; -import { findEnhancementBuild } from "../src/main/certification/enhancement-builds.js"; -import { transformEnhancementWasm } from "../src/main/certification/enhancement-transform.js"; -import { rewriteNativeDoubleClickWasm } from "../src/main/certification/native-double-click.js"; -import { ENHANCEMENT_CAPABILITY_PROFILES } from "../src/shared/enhancement-contracts.js"; -import { certifyClientBuild } from "../src/main/certification/client-certification.js"; -import { prepareClientModule } from "../src/main/certification/client-module.js"; - -const [jsPath, wasmPath] = process.argv.slice(2); -if (!jsPath || !wasmPath) { - throw new Error( - "usage: qualify-extended-memory.ts ", - ); -} - -const sha256 = (bytes: Uint8Array | string): string => - createHash("sha256").update(bytes).digest("hex"); -const [officialJs, officialWasm] = await Promise.all([ - readFile(jsPath, "utf8"), - readFile(wasmPath), -]); -const transformedJs = rewriteExtendedMemoryJs(officialJs); -if (sha256(transformedJs) !== EXTENDED_MEMORY_JS_BUILD.outputSha256) { - throw new Error("transformed JavaScript does not match its certified hash"); -} -new Function(transformedJs); - -const templateBuild = findTemplateSaveBuild(sha256(officialWasm)); -if (!templateBuild) throw new Error("official WASM is not template-save certified"); -const templateWasm = rewriteTemplateSaveWasm(officialWasm, templateBuild); -const enhancementBuild = findEnhancementBuild(sha256(templateWasm)); -if (!enhancementBuild) throw new Error("template output is not Enhancement certified"); - -const predecessors = new Map(); -predecessors.set("off", rewriteNativeDoubleClickWasm(templateWasm)); -for (const [profile, capabilities] of Object.entries( - ENHANCEMENT_CAPABILITY_PROFILES, -)) { - predecessors.set( - profile, - rewriteNativeDoubleClickWasm( - transformEnhancementWasm(templateWasm, enhancementBuild, capabilities), - ), - ); -} - -const scratch = await mkdtemp(join(tmpdir(), "gwonmac-4gb-qualification-")); -try { - const predecessorPath = join(scratch, "Gw.jspi.wasm"); - const cacheRoot = join(scratch, "cache"); - const predecessor = predecessors.get("off"); - if (!predecessor) throw new Error("Enhancements-off predecessor is missing"); - await writeFile(predecessorPath, predecessor); - const first = await prepareExtendedMemoryArtifacts( - jsPath, - predecessorPath, - cacheRoot, - ); - const cached = await prepareExtendedMemoryArtifacts( - jsPath, - predecessorPath, - cacheRoot, - ); - if ( - first?.profile !== "off" - || cached?.jsPath !== first.jsPath - || cached?.wasmPath !== first.wasmPath - ) throw new Error("paired artifact cache did not reproduce its exact output"); - - const changed = Uint8Array.from(predecessor); - changed[changed.byteLength - 1] = changed[changed.byteLength - 1]! ^ 1; - await writeFile(predecessorPath, changed); - if ( - await prepareExtendedMemoryArtifacts(jsPath, predecessorPath, cacheRoot) - !== null - ) throw new Error("changed WASM pair was not refused"); - if (await stat(cacheRoot).then(() => true, () => false)) { - throw new Error("refused pair left a stale derived cache"); - } -} finally { - await rm(scratch, { recursive: true, force: true }); -} - -const variants = []; -let allocatorModule: Uint8Array | null = null; -for (const build of EXTENDED_MEMORY_WASM_BUILDS) { - const predecessor = predecessors.get(build.profile); - if (!predecessor || sha256(predecessor) !== build.inputSha256) { - throw new Error(`${build.profile} predecessor does not match certification`); - } - const output = rewriteExtendedMemoryWasm(predecessor); - if ( - sha256(output) !== build.outputSha256 - || !WebAssembly.validate(Uint8Array.from(output)) - ) { - throw new Error(`${build.profile} output does not match certification`); - } - if (build.profile === "cursorToolbox") allocatorModule = output; - variants.push({ - profile: build.profile, - inputSha256: build.inputSha256, - outputSha256: build.outputSha256, - }); -} -if (!allocatorModule) throw new Error("allocator qualification variant is missing"); - -const selectionScratch = await mkdtemp(join(tmpdir(), "gwonmac-4gb-selection-")); -try { - const officialJsPath = join(selectionScratch, "official", "Gw.jspi.js"); - const officialWasmPath = join(selectionScratch, "official", "Gw.jspi.wasm"); - await mkdir(join(selectionScratch, "official"), { recursive: true }); - await Promise.all([ - writeFile(officialJsPath, officialJs), - writeFile(officialWasmPath, officialWasm), - ]); - const officialSha256 = sha256(officialWasm); - const selected = await prepareClientModule({ - officialJsPath, - officialWasmPath, - officialSha256, - certification: certifyClientBuild(officialSha256), - enhancementCapabilities: { - nativeCursor: false, - targetObservation: false, - toolbox: false, - commands: false, - }, - compatibilityCacheRoot: join(selectionScratch, "compatibility"), - enhancementCacheRoot: join(selectionScratch, "enhancements"), - nativeDoubleClickCacheRoot: join(selectionScratch, "double-click"), - extendedMemoryCacheRoot: join(selectionScratch, "extended-memory"), - extendedMemoryEnabled: true, - }); - if ( - selected.extendedMemory.status !== "active" - || selected.extendedMemory.profile !== "off" - || sha256(await readFile(selected.jsPath)) !== EXTENDED_MEMORY_JS_BUILD.outputSha256 - || sha256(await readFile(selected.wasmPath)) - !== EXTENDED_MEMORY_WASM_BUILDS[0]!.outputSha256 - ) throw new Error("production client selection did not publish the certified pair"); -} finally { - await rm(selectionScratch, { recursive: true, force: true }); -} - -const module = new WebAssembly.Module(Uint8Array.from(allocatorModule)); -const state: { memory?: WebAssembly.Memory } = {}; -const imports: WebAssembly.Imports = {}; -for (const entry of WebAssembly.Module.imports(module)) { - const namespace = (imports[entry.module] ??= {}) as Record; - if (entry.kind !== "function") { - throw new Error(`unsupported ${entry.kind} import ${entry.module}.${entry.name}`); - } - namespace[entry.name] = entry.name === "emscripten_resize_heap" - ? (requested: number) => { - const memory = state.memory; - if (!memory) throw new Error("heap growth requested during instantiation"); - const target = requested >>> 0; - if (target > EXTENDED_MEMORY_MAX_BYTES) return 0; - const current = memory.buffer.byteLength; - if (target <= current) return 1; - memory.grow(Math.ceil((target - current) / 65_536)); - return 1; - } - : () => 0; -} -const instance = await WebAssembly.instantiate(module, imports); -const exports = instance.exports as { - memory: WebAssembly.Memory; - malloc(bytes: number): number; - free(pointer: number): void; -}; -const memory = exports.memory; -state.memory = memory; - -const allocationBytes = 480 * 1_024 * 1_024; -const pointers: number[] = []; -let highPointer: number | undefined; -for (let index = 0; index < 6; index += 1) { - const raw = exports.malloc(allocationBytes); - if (raw === 0) throw new Error(`allocation ${index + 1} failed`); - pointers.push(raw); - if ((raw >>> 0) >= 0x8000_0000) highPointer ??= raw; -} -if (highPointer === undefined || memory.buffer.byteLength <= 0x8000_0000) { - throw new Error("allocator did not cross 2 GiB"); -} - -const highAddress = highPointer >>> 0; -const view = new DataView(memory.buffer); -view.setUint32(highAddress, 0x4757_3447, true); -if (view.getUint32(highAddress, true) !== 0x4757_3447) { - throw new Error("high-address read/write failed"); -} -const capacityBeforeFree = memory.buffer.byteLength; -for (const pointer of pointers) exports.free(pointer); -const reused = exports.malloc(allocationBytes); -if (reused === 0 || memory.buffer.byteLength !== capacityBeforeFree) { - throw new Error("freed allocation was not reused without heap growth"); -} -exports.free(reused); - -console.log(JSON.stringify({ - jsInputSha256: EXTENDED_MEMORY_JS_BUILD.inputSha256, - jsOutputSha256: EXTENDED_MEMORY_JS_BUILD.outputSha256, - variants, - heapBytes: capacityBeforeFree, - highPointerUnsigned: highAddress, - crossed2GiB: true, - freedBlockReusedWithoutGrowth: true, -}, null, 2)); diff --git a/scripts/verify-stable-beta-roundtrip.ts b/scripts/verify-stable-beta-roundtrip.ts index 1b02a988..d0fa9dc4 100644 --- a/scripts/verify-stable-beta-roundtrip.ts +++ b/scripts/verify-stable-beta-roundtrip.ts @@ -160,7 +160,6 @@ const candidateSettingsDomains = Array.from( gwonmacTools: cycle(booleanValues, index), teamManagement: cycle(booleanValues, index + 1), targetReadout: cycle(booleanValues, index), - extendedMemoryEnabled: cycle(booleanValues, index + 1), showDiagnostics: cycle(booleanValues, index), dataStrategy: cycle(DATA_STRATEGIES, index), autoCheckUpdates: false, diff --git a/src/main/active-client.ts b/src/main/active-client.ts index b7e0c68f..52586943 100644 --- a/src/main/active-client.ts +++ b/src/main/active-client.ts @@ -7,10 +7,7 @@ * * The slot records what is current and decides nothing about readiness. */ -import type { - ClientCompatibility, - ExtendedMemoryRuntimeStatus, -} from "../shared/contracts.js"; +import type { ClientCompatibility } from "../shared/contracts.js"; import type { ChunkStore } from "./core/chunk-store.js"; export interface ActiveClient { @@ -20,7 +17,6 @@ export interface ActiveClient { readonly wasmPath: string; readonly jsPath: string; readonly compatibility: ClientCompatibility | null; - readonly extendedMemory: ExtendedMemoryRuntimeStatus; } export type ClientGeneration = Omit; diff --git a/src/main/certification/client-module.ts b/src/main/certification/client-module.ts index a244991f..74122b75 100644 --- a/src/main/certification/client-module.ts +++ b/src/main/certification/client-module.ts @@ -41,11 +41,6 @@ import { NATIVE_DOUBLE_CLICK_TRANSFORM_ABI, rewriteNativeDoubleClickWasm, } from "./native-double-click.js"; -import { - EXTENDED_MEMORY_MAX_BYTES, - prepareExtendedMemoryArtifacts, - type ExtendedMemoryProfile, -} from "./extended-memory.js"; /** * The exact records matched while certifying the official client hash. The @@ -85,26 +80,8 @@ interface PreparedWasmClientModule { readonly nativeDoubleClick: boolean; } -export type ExtendedMemoryMode = - | { readonly status: "disabled" } - | { - readonly status: "active"; - readonly profile: ExtendedMemoryProfile; - readonly effectiveCapBytes: typeof EXTENDED_MEMORY_MAX_BYTES; - } - | { - readonly status: "unavailable"; - readonly reason: "unsupported-client"; - } - | { - readonly status: "unavailable"; - readonly reason: "preparation-failed"; - readonly error: unknown; - }; - export interface PreparedClientModule extends PreparedWasmClientModule { readonly jsPath: string; - readonly extendedMemory: ExtendedMemoryMode; } export interface PrepareClientModuleOptions { @@ -116,8 +93,6 @@ export interface PrepareClientModuleOptions { readonly compatibilityCacheRoot: string; readonly enhancementCacheRoot: string; readonly nativeDoubleClickCacheRoot: string; - readonly extendedMemoryCacheRoot: string; - readonly extendedMemoryEnabled: boolean; } function templateSaveCache( @@ -244,53 +219,13 @@ async function withNativeDoubleClick( export async function prepareClientModule( options: PrepareClientModuleOptions, ): Promise { - const prepared = await withNativeDoubleClick( - await prepareCertifiedChain(options), - options.nativeDoubleClickCacheRoot, - ); - if (!options.extendedMemoryEnabled) { - return { - ...prepared, - jsPath: options.officialJsPath, - extendedMemory: { status: "disabled" }, - }; - } - try { - const extended = await prepareExtendedMemoryArtifacts( - options.officialJsPath, - prepared.wasmPath, - options.extendedMemoryCacheRoot, - ); - return extended - ? { - ...prepared, - jsPath: extended.jsPath, - wasmPath: extended.wasmPath, - extendedMemory: { - status: "active", - profile: extended.profile, - effectiveCapBytes: EXTENDED_MEMORY_MAX_BYTES, - }, - } - : { - ...prepared, - jsPath: options.officialJsPath, - extendedMemory: { - status: "unavailable", - reason: "unsupported-client", - }, - }; - } catch (error) { - return { - ...prepared, - jsPath: options.officialJsPath, - extendedMemory: { - status: "unavailable", - reason: "preparation-failed", - error, - }, - }; - } + return { + ...await withNativeDoubleClick( + await prepareCertifiedChain(options), + options.nativeDoubleClickCacheRoot, + ), + jsPath: options.officialJsPath, + }; } /** diff --git a/src/main/certification/extended-memory.ts b/src/main/certification/extended-memory.ts deleted file mode 100644 index d3c980c9..00000000 --- a/src/main/certification/extended-memory.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Opt-in 4 GiB research profile for exact ArenaNet build 38797. - * - * This is one paired transform. wasm32 addresses above 2 GiB arrive in - * JavaScript as negative i32 values, so publishing the larger WASM memory - * without the matching unsigned-pointer glue is never allowed. - */ -import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, stat } from "node:fs/promises"; -import path from "node:path"; -import { writeAtomic, writeAtomicJson } from "../core/atomic-file.js"; -import { - concat, - encodeSection, - readUleb, - splitSections, - uleb, - WASM_HEADER, - type Section, -} from "../core/wasm-binary.js"; - -declare const WebAssembly: { validate(bytes: Uint8Array): boolean }; - -export const EXTENDED_MEMORY_TRANSFORM_ABI = 1; -export const EXTENDED_MEMORY_MAX_PAGES = 65_535; -export const EXTENDED_MEMORY_MAX_BYTES = EXTENDED_MEMORY_MAX_PAGES * 65_536; -export const EXTENDED_MEMORY_PROFILES = [ - "off", - "cursor", - "target", - "cursorTarget", - "cursorToolbox", - "cursorToolboxCommands", - "cursorTargetToolboxCommands", -] as const; -export type ExtendedMemoryProfile = (typeof EXTENDED_MEMORY_PROFILES)[number]; - -export interface ExtendedMemoryWasmBuild { - readonly buildId: 38_797; - readonly profile: ExtendedMemoryProfile; - readonly inputSha256: string; - readonly outputSha256: string; -} - -/** Every post-double-click variant the current production chain can emit. */ -export const EXTENDED_MEMORY_WASM_BUILDS: readonly ExtendedMemoryWasmBuild[] = - Object.freeze([ - Object.freeze({ - buildId: 38_797 as const, - profile: "off" as const, - inputSha256: "e7d86cfcf7b09abbedd3afca758dbf4a3f3c6e1aa4d44e53b31e45e886d7f250", - outputSha256: "862f97fc87267e3b4d342ea01f15834cc60a7be982fd9741cf0ae31b8a18a00b", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "cursor" as const, - inputSha256: "61dba74fe55c2ba2d8e0d0bb36447fa9112446ea6ea9bae61b944df3b0726d37", - outputSha256: "44d19d9db3b8f917415819e724c0a90e04e310eb54b08787e44d916c449a8f61", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "target" as const, - inputSha256: "e49ab1306b89188125c18963afbcd93913232cee7f61121b68c3797b27c9bcad", - outputSha256: "f7576ac19f9709824a9f504dee4fc952c090e570bf6fe7e134967fdf3975ff50", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "cursorTarget" as const, - inputSha256: "5bf9250016b46f4a348c94b9e3edf7b180fde7570677ce856c802962435b3023", - outputSha256: "b3f6292fc0a0e606b408f5715af02c3c7eeb427e58d9bd01bbba3221cd6f7deb", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "cursorToolbox" as const, - inputSha256: "e6f1e88d8b4326bd1ea6246bb07443c1661c4d1904d78289be49da00859a6648", - outputSha256: "510a369604235779da6d2ea7fa657087544a2d271718f0a048d942abb5a496bd", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "cursorToolboxCommands" as const, - inputSha256: "d95c9ce5ceb7a010261357f4927a434725e33844215d0fda8b7579ae96758a08", - outputSha256: "5ba907eb8182638854fa5b11e0951b78877656393ea97f877fde1b382ac7f5cd", - }), - Object.freeze({ - buildId: 38_797 as const, - profile: "cursorTargetToolboxCommands" as const, - inputSha256: "d2efaa8f460acf0d13cf1c1bfe319bbf94d5fde22d9a96de8a9485630c069c51", - outputSha256: "1aac83397e09d3c42a801ba9447da959b62b04d980370c799e8db927cb1cb4cd", - }), - ]); - -export const EXTENDED_MEMORY_JS_BUILD = Object.freeze({ - buildId: 38_797, - inputSha256: "58ecc6377397f01919d8def58e802e19fbfd6ce13f421dbf14123a667e34f7d0", - outputSha256: "1dd5d798b1491f46a7c128c641053c8488211bbc193fe40bdf1d7a886517993d", -}); - -const sha256 = (bytes: Uint8Array | string): string => - createHash("sha256").update(bytes).digest("hex"); - -function fail(message: string): never { - throw new Error(`extended-memory transform: ${message}`); -} - -function replaceExactly( - source: string, - search: string, - replacement: string, - expectedCount: number, -): string { - const parts = source.split(search); - if (parts.length - 1 !== expectedCount) { - fail(`expected ${expectedCount} occurrences of ${JSON.stringify(search)}`); - } - return parts.join(replacement); -} - -export function findExtendedMemoryWasmBuild( - inputSha256: string, -): ExtendedMemoryWasmBuild | null { - return EXTENDED_MEMORY_WASM_BUILDS.find( - (build) => build.inputSha256 === inputSha256, - ) ?? null; -} - -/** Raise the sole defined memory from 32,768 to 65,535 pages. */ -export function rewriteExtendedMemoryWasm(input: Uint8Array): Uint8Array { - const build = findExtendedMemoryWasmBuild(sha256(input)); - if (!build) fail("uncertified WASM input"); - const sections = splitSections(input); - const memory = sections.find((section) => section.id === 5) - ?? fail("missing memory section"); - const cursor = { offset: 0 }; - const count = readUleb(memory.body, cursor); - const flags = readUleb(memory.body, cursor); - const initial = readUleb(memory.body, cursor); - const maximum = readUleb(memory.body, cursor); - if ( - count !== 1 || flags !== 1 || initial !== 4_096 || maximum !== 32_768 - || cursor.offset !== memory.body.byteLength - ) { - fail("memory declaration is not the certified 256 MiB / 2 GiB shape"); - } - const replacement: Section = { - id: 5, - body: concat(uleb(1), uleb(1), uleb(initial), uleb(EXTENDED_MEMORY_MAX_PAGES)), - }; - const output = concat( - WASM_HEADER, - ...sections.map((section) => - encodeSection(section === memory ? replacement : section)), - ); - if (!WebAssembly.validate(output)) fail("rewritten module does not validate"); - if (sha256(output) !== build.outputSha256) fail("derived WASM hash changed"); - return output; -} - -/** - * Apply Emscripten's CAN_ADDRESS_2GB unsigned-pointer lowering to the pinned - * generated glue. The shifts and heap accesses were audited for this exact - * input; this function never accepts an arbitrary script. - */ -export function rewriteExtendedMemoryJs(input: string): string { - if (sha256(input) !== EXTENDED_MEMORY_JS_BUILD.inputSha256) { - fail("uncertified JavaScript input"); - } - let output = replaceExactly(input, " 2147483648;", " 4294901760;", 1); - output = replaceExactly( - output, - " 4294901760;\r\n \r\n var alignMemory", - " 4294901760;\r\n Module['gwonmacHeapCapBytes'] = 4294901760;\r\n \r\n var alignMemory", - 1, - ); - - let signedShiftCount = 0; - output = output.replace(/(?)>>(?!>)/g, () => { - signedShiftCount += 1; - return ">>>"; - }); - if (signedShiftCount !== 327) fail("signed-shift audit count changed"); - - let heapIndexCount = 0; - output = output.replace( - /\b(HEAP(?:U?8|U?16|U?32|F32|F64))\[([^\]\r\n]+)\]/g, - (_whole, heap: string, index: string) => { - heapIndexCount += 1; - return `${heap}[((${index}) >>> 0)]`; - }, - ); - if (heapIndexCount !== 341) fail("heap-index audit count changed"); - - output = replaceExactly( - output, - " var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => {\r\n ", - " var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => {\r\n idx >>>= 0;\r\n ", - 1, - ); - output = replaceExactly( - output, - " var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => {\r\n", - " var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => {\r\n outIdx >>>= 0;\r\n", - 1, - ); - - let viewOffsetCount = 0; - output = output.replace( - /new ((?:Big)?Uint(?:8|16|32|64)Array)\(Module\.HEAPU8\.buffer, ([^,\r\n]+),/g, - (_whole, view: string, offset: string) => { - viewOffsetCount += 1; - return `new ${view}(Module.HEAPU8.buffer, ((${offset}) >>> 0),`; - }, - ); - if (viewOffsetCount !== 7) fail("typed-array offset audit count changed"); - - output = replaceExactly( - output, - "HEAP8.set(contents, ptr);", - "HEAP8.set(contents, ptr >>> 0);", - 1, - ); - output = replaceExactly( - output, - "Module.HEAPU8.set(data, dataPtr);", - "Module.HEAPU8.set(data, dataPtr >>> 0);", - 1, - ); - output = replaceExactly( - output, - "Module.HEAPU8.set(array, responseBody);", - "Module.HEAPU8.set(array, responseBody >>> 0);", - 1, - ); - output = replaceExactly( - output, - "Module.image.readAsync(imageId, offset, null, buffer, bytes)", - "Module.image.readAsync(imageId, offset, null, buffer >>> 0, bytes)", - 2, - ); - if (sha256(output) !== EXTENDED_MEMORY_JS_BUILD.outputSha256) { - fail("derived JavaScript hash changed"); - } - return output; -} - -export interface ExtendedMemoryArtifacts { - readonly jsPath: string; - readonly wasmPath: string; - readonly profile: ExtendedMemoryProfile; -} - -interface ExtendedMemoryMetadata { - abi?: unknown; - jsInputSha256?: unknown; - jsOutputSha256?: unknown; - wasmInputSha256?: unknown; - wasmOutputSha256?: unknown; - profile?: unknown; -} - -function artifactPaths( - cacheRoot: string, - build: ExtendedMemoryWasmBuild, -): Omit & { - readonly cacheDir: string; - readonly metadataPath: string; -} { - const identity = sha256( - `${EXTENDED_MEMORY_JS_BUILD.inputSha256}:${build.inputSha256}`, - ); - const cacheDir = path.join(cacheRoot, identity, String(EXTENDED_MEMORY_TRANSFORM_ABI)); - return { - cacheDir, - jsPath: path.join(cacheDir, "Gw.jspi.js"), - wasmPath: path.join(cacheDir, "Gw.jspi.wasm"), - metadataPath: path.join(cacheDir, "metadata.json"), - }; -} - -async function fileSha256(filePath: string): Promise { - return sha256(await readFile(filePath)); -} - -async function usable( - cacheRoot: string, - build: ExtendedMemoryWasmBuild, -): Promise { - const files = artifactPaths(cacheRoot, build); - try { - const metadata = JSON.parse( - await readFile(files.metadataPath, "utf8"), - ) as ExtendedMemoryMetadata; - if ( - metadata.abi !== EXTENDED_MEMORY_TRANSFORM_ABI - || metadata.jsInputSha256 !== EXTENDED_MEMORY_JS_BUILD.inputSha256 - || metadata.jsOutputSha256 !== EXTENDED_MEMORY_JS_BUILD.outputSha256 - || metadata.wasmInputSha256 !== build.inputSha256 - || metadata.wasmOutputSha256 !== build.outputSha256 - || metadata.profile !== build.profile - ) return false; - const [jsStat, wasmStat, jsHash, wasmHash] = await Promise.all([ - stat(files.jsPath), - stat(files.wasmPath), - fileSha256(files.jsPath), - fileSha256(files.wasmPath), - ]); - return jsStat.isFile() && wasmStat.isFile() - && jsHash === EXTENDED_MEMORY_JS_BUILD.outputSha256 - && wasmHash === build.outputSha256; - } catch { - return false; - } -} - -/** - * Atomically selects a certified JS/WASM pair. `null` means this exact pair is - * unsupported and both official artifacts must be served unchanged. - */ -export async function prepareExtendedMemoryArtifacts( - officialJsPath: string, - inputWasmPath: string, - cacheRoot: string, -): Promise { - const [jsInput, wasmInput] = await Promise.all([ - readFile(officialJsPath, "utf8"), - readFile(inputWasmPath), - ]); - const build = findExtendedMemoryWasmBuild(sha256(wasmInput)); - if (sha256(jsInput) !== EXTENDED_MEMORY_JS_BUILD.inputSha256 || !build) { - await rm(cacheRoot, { recursive: true, force: true }); - return null; - } - const files = artifactPaths(cacheRoot, build); - if (await usable(cacheRoot, build)) { - return { jsPath: files.jsPath, wasmPath: files.wasmPath, profile: build.profile }; - } - - const jsOutput = rewriteExtendedMemoryJs(jsInput); - const wasmOutput = rewriteExtendedMemoryWasm(wasmInput); - await rm(cacheRoot, { recursive: true, force: true }); - await mkdir(files.cacheDir, { recursive: true }); - await Promise.all([ - writeAtomic(files.jsPath, jsOutput), - writeAtomic(files.wasmPath, wasmOutput), - ]); - await writeAtomicJson(files.metadataPath, { - abi: EXTENDED_MEMORY_TRANSFORM_ABI, - jsInputSha256: EXTENDED_MEMORY_JS_BUILD.inputSha256, - jsOutputSha256: EXTENDED_MEMORY_JS_BUILD.outputSha256, - wasmInputSha256: build.inputSha256, - wasmOutputSha256: build.outputSha256, - profile: build.profile, - }); - if (!await usable(cacheRoot, build)) { - fail("published artifact pair failed verification"); - } - return { jsPath: files.jsPath, wasmPath: files.wasmPath, profile: build.profile }; -} diff --git a/src/main/client-runtime.ts b/src/main/client-runtime.ts index 6f86a999..f144be87 100644 --- a/src/main/client-runtime.ts +++ b/src/main/client-runtime.ts @@ -25,7 +25,6 @@ import { type ClientHealthToken, type DownloadActivity, type DownloadFailure, - type ExtendedMemoryRuntimeStatus, type DownloadProgress, type FullDownloadOutcome, type NoticeCode, @@ -86,7 +85,6 @@ import { } from "./diagnostics.js"; import type { GamePaths } from "./paths.js"; import { verifyClientLocally } from "./certification/local-client-verifier-host.js"; -import { extendedMemoryRuntimeStatus } from "./extended-memory-runtime.js"; export type { ActiveClient } from "./active-client.js"; @@ -104,7 +102,6 @@ interface ClientRuntimeOptions { hostVersion: string; cachedOnly: boolean; enhancementCapabilities: EnhancementCapabilities; - extendedMemoryEnabled: boolean; onProgress: (progress: DownloadProgress) => void; onPrefetch: (progress: PrefetchProgress) => void; } @@ -156,10 +153,6 @@ export class ClientRuntime { return this.candidateHealthToken; } - get extendedMemory(): ExtendedMemoryRuntimeStatus | null { - return this.activeSlot.current?.extendedMemory ?? null; - } - get progress(): DownloadProgress { return this.progressValue; } @@ -222,7 +215,6 @@ export class ClientRuntime { wasmPath: string; jsPath: string; compatibility: ClientCompatibility | null; - extendedMemory: ExtendedMemoryRuntimeStatus; }> { const officialWasm = clientArtifactPath( this.options.paths.artifacts, @@ -238,16 +230,10 @@ export class ClientRuntime { logEvent({ k: "wasm.clientHashUnavailable", code: errorCode(error), }); - const extendedMemory = extendedMemoryRuntimeStatus( - this.options.extendedMemoryEnabled - ? { status: "unavailable", reason: "unsupported-client" } - : { status: "disabled" }, - ); return { wasmPath: officialWasm, jsPath: clientArtifactPath(this.options.paths.artifacts, "Gw.jspi.js"), compatibility: null, - extendedMemory, }; } @@ -276,8 +262,6 @@ export class ClientRuntime { compatibilityCacheRoot: this.options.paths.compatibility, enhancementCacheRoot: this.options.paths.enhancements, nativeDoubleClickCacheRoot: this.options.paths.nativeDoubleClick, - extendedMemoryCacheRoot: this.options.paths.extendedMemory, - extendedMemoryEnabled: this.options.extendedMemoryEnabled, }); const state = prepared.state; const compatibility: ClientCompatibility = { @@ -322,35 +306,10 @@ export class ClientRuntime { logEvent({ k: "enhancement.uncertifiedClientBlocked" }); } gauge("enhancement.supportedBuild", prepared.enhancementBuild !== null); - const extendedMemory = extendedMemoryRuntimeStatus(prepared.extendedMemory); - const extendedCap = extendedMemory.effectiveCapBytes; - const fallbackReason = extendedMemory.fallbackReason; - gauge("wasm.extendedMemoryMode", prepared.extendedMemory.status); - gauge("wasm.heapCapBytes", extendedCap); - logEvent({ - k: "wasm.extendedMemory", - mode: prepared.extendedMemory.status, - requested: this.options.extendedMemoryEnabled, - profile: prepared.extendedMemory.status === "active" - ? prepared.extendedMemory.profile - : "none", - capBytes: extendedCap, - fallbackReason: fallbackReason ?? "none", - }); - if ( - prepared.extendedMemory.status === "unavailable" - && prepared.extendedMemory.reason === "preparation-failed" - ) { - logEvent({ - k: "wasm.extendedMemoryPrepareFailed", - code: errorCode(prepared.extendedMemory.error), - }); - } return { wasmPath: prepared.wasmPath, jsPath: prepared.jsPath, compatibility, - extendedMemory, }; } @@ -387,7 +346,6 @@ export class ClientRuntime { wasmPath: enhancement.wasmPath, jsPath: enhancement.jsPath, compatibility: enhancement.compatibility, - extendedMemory: enhancement.extendedMemory, }); this.candidateHealthToken = candidateFingerprint ? Object.freeze({ diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 356bbb17..38fd7187 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -28,7 +28,6 @@ export interface GamePaths { compatibility: string; enhancements: string; nativeDoubleClick: string; - extendedMemory: string; chunks: string; bootChunks: string; skillAssets: string; @@ -52,7 +51,6 @@ export function gamePaths(userData: string): GamePaths { compatibility: path.join(game, "compatibility"), enhancements: path.join(game, "enhancements"), nativeDoubleClick: path.join(game, "double-click"), - extendedMemory: path.join(game, "extended-memory"), chunks: path.join(game, "chunks"), bootChunks: path.join(game, "boot-chunks.json"), // Icons and text decoded out of the player's own archive, under a diff --git a/src/main/core/retired-extended-memory.ts b/src/main/core/retired-extended-memory.ts new file mode 100644 index 00000000..e13169e3 --- /dev/null +++ b/src/main/core/retired-extended-memory.ts @@ -0,0 +1,84 @@ +/** + * One bounded retirement for the withdrawn post-build 4 GiB profile. + * + * The raw settings document is rewritten instead of being round-tripped + * through AppSettings so this one-time cleanup deletes only the retired key. + * The executable transform no longer exists, so every failure is safe: it is + * reported to the caller but never blocks the ordinary 2 GiB client. + */ +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { writeAtomicJson } from "./atomic-file.js"; +import { parseSettings } from "./settings.js"; + +const RETIRED_SETTING = "extendedMemoryEnabled"; +const RETIRED_CACHE_DIRECTORY = "extended-memory"; + +export interface RetiredExtendedMemoryMigration { + readonly wasEnabled: boolean; + readonly persistenceError: unknown | null; +} + +const noMigration = (): RetiredExtendedMemoryMigration => ({ + wasEnabled: false, + persistenceError: null, +}); + +export async function retireExtendedMemorySetting( + settingsPath: string, + persist: typeof writeAtomicJson = writeAtomicJson, +): Promise { + let text: string; + try { + text = await readFile(settingsPath, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" + ? noMigration() + : { wasEnabled: false, persistenceError: error }; + } + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + // loadSettings owns malformed JSON and its preserved recovery copy. + return noMigration(); + } + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return noMigration(); + } + const document = raw as Record; + if (!Object.hasOwn(document, RETIRED_SETTING)) return noMigration(); + + const migrated = { ...document }; + const wasEnabled = migrated[RETIRED_SETTING] === true; + delete migrated[RETIRED_SETTING]; + try { + // Do not rewrite a document the ordinary settings reader would preserve + // as corrupt. Its existing recovery path must remain the only owner. + parseSettings(migrated); + } catch { + return noMigration(); + } + try { + await persist(settingsPath, migrated); + return { wasEnabled, persistenceError: null }; + } catch (error) { + return { wasEnabled, persistenceError: error }; + } +} + +export async function discardRetiredExtendedMemoryCache( + gameRoot: string, +): Promise { + try { + await rm(path.join(gameRoot, RETIRED_CACHE_DIRECTORY), { + recursive: true, + force: true, + }); + return null; + } catch (error) { + return error; + } +} diff --git a/src/main/core/settings.ts b/src/main/core/settings.ts index 575398e8..d7171264 100644 --- a/src/main/core/settings.ts +++ b/src/main/core/settings.ts @@ -121,7 +121,6 @@ export function parseSettings(raw: unknown): AppSettings { "gwonmacTools", "teamManagement", "targetReadout", - "extendedMemoryEnabled", ] as const) { if (setting in src) out[setting] = asBool(src[setting], setting); } diff --git a/src/main/diagnostics/schema-app-update.ts b/src/main/diagnostics/schema-app-update.ts index 1c4ff4c7..8f6ccfd8 100644 --- a/src/main/diagnostics/schema-app-update.ts +++ b/src/main/diagnostics/schema-app-update.ts @@ -3,7 +3,6 @@ * It keeps that half of the closed schema reviewable as one bounded table. */ import type { EventSpec } from "./schema-fields.js"; -import { EXTENDED_MEMORY_PROFILES } from "../certification/extended-memory.js"; import { appPhase, appUpdateErrorCode, @@ -362,6 +361,14 @@ export const APP_AND_UPDATE_EVENT_SCHEMA = { level: "error", fields: none, }, + "memoryProfile.retirementFailed": { + subsystem: "app", + level: "warn", + fields: { + target: literal(["settings", "cache"] as const), + code, + }, + }, "settings.loadFailed": { subsystem: "settings", level: "error", @@ -593,12 +600,21 @@ export const APP_AND_UPDATE_EVENT_SCHEMA = { fields: { code }, }, "wasm.extendedMemory": { + // Legacy archive compatibility only. The executable profile was withdrawn + // and production no longer emits either extended-memory event. subsystem: "wasm", level: "info", fields: { mode: literal(["disabled", "unavailable", "active"] as const), requested: boolean, - profile: literal(["none", ...EXTENDED_MEMORY_PROFILES] as const), + profile: literal([ + "none", + "off", + "cursor", + "target", + "cursorTarget", + "cursorToolbox", + ] as const), capBytes: finiteNumber, fallbackReason: literal([ "none", diff --git a/src/main/extended-memory-runtime.ts b/src/main/extended-memory-runtime.ts deleted file mode 100644 index 8fb50df7..00000000 --- a/src/main/extended-memory-runtime.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Extended-memory runtime truth. - * Projects certified module selection into the one status published to the renderer. - */ -import { - WASM_HEAP_CAP_BYTES, - type ExtendedMemoryRuntimeStatus, -} from "../shared/contracts.js"; -import type { ExtendedMemoryMode } from "./certification/client-module.js"; - -/** The single projection from module selection into user-visible runtime truth. */ -export function extendedMemoryRuntimeStatus( - mode: ExtendedMemoryMode, -): ExtendedMemoryRuntimeStatus { - switch (mode.status) { - case "disabled": - return { - requestedAtLaunch: false, - status: "standard", - effectiveCapBytes: WASM_HEAP_CAP_BYTES, - fallbackReason: null, - }; - case "active": - return { - requestedAtLaunch: true, - status: "active", - effectiveCapBytes: mode.effectiveCapBytes, - fallbackReason: null, - }; - case "unavailable": - return { - requestedAtLaunch: true, - status: "unavailable", - effectiveCapBytes: WASM_HEAP_CAP_BYTES, - fallbackReason: mode.reason, - }; - } -} diff --git a/src/main/main.ts b/src/main/main.ts index 349dbe28..23fccfe0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -39,6 +39,10 @@ import { AUTOMATION_COMMAND } from "../shared/automation.js"; import { ClientRuntime } from "./client-runtime.js"; import { Mutex } from "./core/mutex.js"; import { loadSettings, saveSettings } from "./core/settings.js"; +import { + discardRetiredExtendedMemoryCache, + retireExtendedMemorySetting, +} from "./core/retired-extended-memory.js"; import { SocketManager } from "./core/sockets.js"; import { count, @@ -354,6 +358,22 @@ if (primaryInstance) void app.whenReady().then(async () => { await applyPendingGameStorageReset(paths); await ensureDirs(); await startDiagnostics(); + const retiredMemory = await retireExtendedMemorySetting(paths.settings); + if (retiredMemory.persistenceError) { + logEvent({ + k: "memoryProfile.retirementFailed", + target: "settings", + code: errorCode(retiredMemory.persistenceError), + }); + } + const retiredCacheError = await discardRetiredExtendedMemoryCache(paths.game); + if (retiredCacheError) { + logEvent({ + k: "memoryProfile.retirementFailed", + target: "cache", + code: errorCode(retiredCacheError), + }); + } const distributionChannel = packagedDistributionChannel(); const distribution = distributionCapabilities(distributionChannel); if (!app.isPackaged) { @@ -387,6 +407,15 @@ if (primaryInstance) void app.whenReady().then(async () => { "The settings file was corrupt. Defaults were restored and a diagnostic copy was preserved.", }); }); + if (retiredMemory.wasEnabled) { + await dialog.showMessageBox({ + type: "warning", + buttons: ["Continue"], + message: "Experimental 4 GB memory limit removed", + detail: + "We found that the experimental mode can cause severe graphical corruption during long sessions. GWonMac has restored the standard 2 GB limit. The memory warning and Reload Guild Wars recovery remain available.", + }); + } const enhancementSelection = enhancementSelectionFor(settings); const enhancementProgram = DEVELOPER_ENHANCEMENT_PROGRAM; const enhancementCapabilities = enhancementCapabilitiesFor( @@ -412,7 +441,6 @@ if (primaryInstance) void app.whenReady().then(async () => { hostVersion: HOST_VERSION, cachedOnly: process.env.GW_REQUIRE_CACHED_CLIENT === "1", enhancementCapabilities, - extendedMemoryEnabled: settings.extendedMemoryEnabled, onProgress: setProgress, onPrefetch: setPrefetch, }); @@ -527,7 +555,6 @@ if (primaryInstance) void app.whenReady().then(async () => { getClientSession: () => ({ appVersion: HOST_VERSION, compatibility: clientRuntime.compatibility, - extendedMemory: clientRuntime.extendedMemory, healthToken: clientRuntime.healthToken, }), acquireSteamToken: (parent, record) => diff --git a/src/renderer/extended-memory-setting.ts b/src/renderer/extended-memory-setting.ts deleted file mode 100644 index e55fb4dc..00000000 --- a/src/renderer/extended-memory-setting.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Extended-memory setting presentation. - * Derives and binds its copy from saved intent and effective runtime status. - */ -import type { ExtendedMemoryRuntimeStatus } from "../shared/contracts.js"; - -export type ExtendedMemoryView = Readonly<{ - label: string; - level: "neutral" | "good" | "warn"; - detail: string; -}>; - -export function extendedMemoryView( - savedIntent: boolean, - runtime: ExtendedMemoryRuntimeStatus | null, -): ExtendedMemoryView { - if (!runtime) { - return savedIntent - ? { - label: "Checking compatibility…", - level: "neutral", - detail: "GWonMac will use 4 GB only after this Guild Wars build passes certification.", - } - : { - label: "Using 2 GB", - level: "neutral", - detail: "The standard certified memory limit is active.", - }; - } - if (savedIntent !== runtime.requestedAtLaunch) { - return { - label: "Restart required", - level: "warn", - detail: `This session is still using ${runtime.effectiveCapBytes > 3_000_000_000 ? "4 GB" : "2 GB"}. Restart GWonMac to apply the saved choice.`, - }; - } - if (runtime.status === "active") { - return { - label: "Using 4 GB", - level: "good", - detail: "The certified 4 GB memory module is active for this session.", - }; - } - if (runtime.status === "unavailable") { - return { - label: "Unavailable for this Guild Wars update", - level: "warn", - detail: runtime.fallbackReason === "preparation-failed" - ? "The 4 GB module could not be prepared safely. Guild Wars started normally with 2 GB; gameplay is not blocked." - : "This Guild Wars build has not passed 4 GB certification yet. Guild Wars started normally with 2 GB; gameplay is not blocked.", - }; - } - return { - label: "Using 2 GB", - level: "neutral", - detail: "The standard certified memory limit is active.", - }; -} - -export function bindExtendedMemorySetting(document: Document): Readonly<{ - render(savedIntent: boolean, runtime: ExtendedMemoryRuntimeStatus | null): void; -}> { - const badge = document.getElementById("settings-memory-badge"); - const status = document.getElementById("settings-memory-status"); - if (!badge || !status) throw new Error("missing extended-memory settings elements"); - return { - render(savedIntent, runtime) { - const view = extendedMemoryView(savedIntent, runtime); - badge.textContent = view.label; - badge.dataset.level = view.level; - status.textContent = view.detail; - }, - }; -} diff --git a/src/renderer/failure-messages.ts b/src/renderer/failure-messages.ts index bf8c9c07..4c1cd1f5 100644 --- a/src/renderer/failure-messages.ts +++ b/src/renderer/failure-messages.ts @@ -264,9 +264,8 @@ export function memoryWarningCopy( dismissButton: MEMORY_DISMISS, explanation: `This session can use up to ${capGb} GB. Reloading starts Guild Wars ` - + 'with fresh memory and puts you back where you were. The experimental ' - + '4 GB limit provides more headroom, but it cannot stop continued ' - + 'memory growth.', + + 'with fresh memory and puts you back where you were. Reload before ' + + 'continued memory growth reaches the client limit.', }; } diff --git a/src/renderer/harness.ts b/src/renderer/harness.ts index 8e9d369c..a98c34be 100644 --- a/src/renderer/harness.ts +++ b/src/renderer/harness.ts @@ -109,7 +109,6 @@ type GwGameModule = { // Published by the generated glue, so absent until it has run. HEAPU8?: Uint8Array; - gwonmacHeapCapBytes?: number; SDL2?: { audioContext?: AudioContext }; audioContext?: AudioContext; oskIsActive?: boolean; @@ -366,9 +365,8 @@ function requestHeapCap() { { createHeapPressureWatch }, { bindMemoryWarning }, ]) => { - const capBytes = Module.gwonmacHeapCapBytes ?? WASM_HEAP_CAP_BYTES; - heapCapBytes = capBytes; - heapWatch = createHeapPressureWatch({ capBytes }); + heapCapBytes = WASM_HEAP_CAP_BYTES; + heapWatch = createHeapPressureWatch({ capBytes: WASM_HEAP_CAP_BYTES }); heapWarning = bindMemoryWarning(document, reloadClientSafely); }) // A failed load retries on the next tick rather than silencing the diff --git a/src/renderer/index.html b/src/renderer/index.html index acf8708b..b969bac9 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -534,25 +534,6 @@

Updates

aria-labelledby="settings-tab-advanced">

Advanced

Diagnostics and recovery options for troubleshooting GWonMac.

-
-
- - - Using 2 GB - -
-

- Gives Guild Wars more memory for long sessions and may delay memory-related crashes. It does not stop memory that keeps growing. Restart GWonMac after changing this option. -

-

-
-