diff --git a/apps/tools/src/EliteSkillsApp.test.ts b/apps/tools/src/EliteSkillsApp.test.ts index 887b7c4d9..45ea2cc3a 100644 --- a/apps/tools/src/EliteSkillsApp.test.ts +++ b/apps/tools/src/EliteSkillsApp.test.ts @@ -42,6 +42,12 @@ describe("Elite Skills", () => { expect(wrapper.find('.elite-panel').exists()).toBe(false); expect(wrapper.get('.elite-mission-tracker').text()).toContain('Lissah'); expect(wrapper.findAll('.elite-marker')).toHaveLength(1); + await wrapper.get('.elite-marker').trigger('pointerenter'); + expect(wrapper.find('.elite-preview').exists()).toBe(true); + await wrapper.setProps({ view: { ...eliteFixtureView(true), mission: null } }); + expect(wrapper.find('.elite-preview').exists()).toBe(false); + expect(wrapper.find('.elite-marker').exists()).toBe(false); + expect(wrapper.find('.elite-mission-tracker').exists()).toBe(false); const missionView = eliteFixtureView(true); await wrapper.setProps({ view: { ...missionView, mission: { box: missionView.mission!.box, transform: null } } }); expect(wrapper.findAll('.elite-marker')).toHaveLength(0); diff --git a/apps/tools/src/EliteSkillsApp.vue b/apps/tools/src/EliteSkillsApp.vue index ce5705fff..64e60beca 100644 --- a/apps/tools/src/EliteSkillsApp.vue +++ b/apps/tools/src/EliteSkillsApp.vue @@ -157,6 +157,7 @@ function cannotCapture(location: EliteLocation) { return /impossible to capture/ function toggleTrack(id: number) { void plan.change({ kind: tracked(id) ? "remove" : "track", skillId: id }); } watch(character, () => { selectedId.value = null; preview.value = null; }); watch(() => props.view.world, (value, previous) => { if (!value && previous) setOpen(false); }); +watch(() => props.view.mission, (value, previous) => { if (!value && previous) preview.value = null; }); let choseDefaultProfession = false; watch(() => party.value.player?.professions, (pair) => { if (!choseDefaultProfession && pair) { profession.value = pair[0]; choseDefaultProfession = true; } diff --git a/docs/cartography.md b/docs/cartography.md index ea988a9c2..6043ee194 100644 --- a/docs/cartography.md +++ b/docs/cartography.md @@ -93,9 +93,12 @@ coverage, and guidance. The compact control remains visible and explains this limited mode when opened. Settings remain intact and apply again automatically after travel to a fully supported area. -The World Map projection and visibility come from one complete native event -context. Unrelated or incomplete events leave the last complete reading intact, -while a validated close event hides the overlay. +The World Map projection comes from a complete native event context. +Unrelated or incomplete events leave the last complete projection intact. +Each presentation read also checks the certified native display flag, which +clears before the close fade starts. Closing withdraws all World Map overlays; +reopening requires another complete projection. Mission Map and Compass +readings reject hidden frames and frames that are being destroyed. At 18 pixels or more per cell, the map draws individual amber diamonds and orange actionable markers. At 8–18 pixels it groups the global grid into 4×4 diff --git a/src/main/certification/cartography-transform-internals.ts b/src/main/certification/cartography-transform-internals.ts index ad9688220..dd1221df2 100644 --- a/src/main/certification/cartography-transform-internals.ts +++ b/src/main/certification/cartography-transform-internals.ts @@ -458,7 +458,7 @@ export function nativeFrameObserver( local(5), i32Load(certificate.frameId), globalSet(globals.frameId), local(5), i32Load(certificate.frameState), Uint8Array.of(0x22), uleb(3), i32(4), Uint8Array.of(0x71, 0x45, 0x45), - local(3), i32(0x200), Uint8Array.of(0x71, 0x45, 0x71), globalSet(globals.visible), + local(3), i32(0x208), Uint8Array.of(0x71, 0x45, 0x71), globalSet(globals.visible), local(5), f32Load(certificate.frameViewportWidth), globalSet(globals.viewportWidth), local(5), f32Load(certificate.frameViewportHeight), globalSet(globals.viewportHeight), local(5), f32Load(certificate.frameScreenLeft), globalSet(globals.left), @@ -550,6 +550,22 @@ export function missionMapEventWrapper( ); } +/** Withdraw the retained World Map frame as soon as native closing begins. */ +export function worldMapVisibilityObserver( + globals: Pick, + stateAddress: number, +): Uint8Array { + return concat( + Uint8Array.of(0x00), + globalGet(globals.visible), Uint8Array.of(0x04, 0x40), + i32(stateAddress), i32Load(), i32(0x80000), Uint8Array.of(0x71, 0x45, 0x04, 0x40), + i32(0), globalSet(globals.visible), + globalGet(globals.sequence), i32(1), Uint8Array.of(0x6a), globalSet(globals.sequence), + Uint8Array.of(0x0b), + Uint8Array.of(0x0b, 0x0b), + ); +} + /** Observe the dedicated native World Map context after its exact event handler. */ export function worldMapEventWrapper( dispatcher: number, diff --git a/src/main/certification/pathing-spike-transform.ts b/src/main/certification/pathing-spike-transform.ts index cc39a6bc4..cac296bd1 100644 --- a/src/main/certification/pathing-spike-transform.ts +++ b/src/main/certification/pathing-spike-transform.ts @@ -17,6 +17,7 @@ import { MISSION_MAP_FRAME_SPIKE_SCALARS, MISSION_MAP_PROJECTION_SPIKE_SCALARS, WORLD_MAP_FRAME_SPIKE_SCALARS, + WORLD_MAP_FRAME_SPIKE_GLOBALS, WORLD_MAP_ANCHOR_SPIKE_GLOBALS, WORLD_MAP_ANCHOR_SPIKE_SCALARS, } from "../../shared/cartography-spike.js"; @@ -38,6 +39,7 @@ import { type Section, } from "../core/wasm-binary.js"; import { functionBodySha256, wasmEvidence } from "./wasm-evidence.js"; +import { worldMapDisplayStateAddress } from "./world-map-visibility-proof.js"; import { CARTOGRAPHY_MEMORY_LAYOUTS, COMPASS_CERTIFICATE, @@ -57,6 +59,7 @@ import { rewriteExactTableSlot, worldMapAnchorObserver, worldMapEventWrapper, + worldMapVisibilityObserver, type CartographyContextGlobals, type CartographyMemoryLayout, type CompassGlobals, @@ -72,7 +75,7 @@ declare const WebAssembly: { Module: new (bytes: Uint8Array) => object; }; -export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 30; +export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 31; export type CartographyMemoryLayoutId = keyof typeof CARTOGRAPHY_MEMORY_LAYOUTS; const mutableI32 = () => Uint8Array.of(0x7f, 0x01, 0x41, 0x00, 0x0b); @@ -245,6 +248,8 @@ export function transformCartographySpikeWasm( }); const evidence = wasmEvidence(input) ?? fail("invalid WebAssembly input"); const module = evidence.moduleView(); + const worldMapDisplayState = worldMapDisplayStateAddress(evidence) + ?? fail("World Map display-state certificate changed"); if ( functionBodySha256(module, COMPASS_CERTIFICATE.renderFunction) !== COMPASS_CERTIFICATE.renderBodySha256 @@ -276,6 +281,7 @@ export function transformCartographySpikeWasm( ...WORLD_MAP_FRAME_SPIKE_SCALARS, ]; const functionNames = [ + WORLD_MAP_FRAME_SPIKE_GLOBALS.observe, CARTOGRAPHY_CONTEXT_GLOBALS.observe, COMPASS_FRAME_SPIKE_GLOBALS.observe, MISSION_MAP_FRAME_SPIKE_GLOBALS.observe, @@ -321,6 +327,7 @@ export function transformCartographySpikeWasm( const explorationReadWordFunction = firstFunction + 6; const anchorObserverFunction = firstFunction + 7; const worldEventWrapperFunction = firstFunction + 8; + const worldVisibilityObserverFunction = firstFunction + 9; const compassRender = bodies[compassRenderLocal]?.slice() ?? fail("Compass render body is missing"); @@ -377,6 +384,7 @@ export function transformCartographySpikeWasm( allocated.context.areaEpoch, worldCertificate, ), + worldMapVisibilityObserver(allocated.world, worldMapDisplayState), ); const nextFunctionTypes = [ ...functionTypes, @@ -389,6 +397,7 @@ export function transformCartographySpikeWasm( readWordType, voidType, worldDispatcherType, + voidType, ]; const contextEntries = [ @@ -424,6 +433,7 @@ export function transformCartographySpikeWasm( [WORLD_MAP_FRAME_SPIKE_SCALARS, allocated.firstWorld], ] as const; const functionPlans = [ + [WORLD_MAP_FRAME_SPIKE_GLOBALS.observe, worldVisibilityObserverFunction], [CARTOGRAPHY_CONTEXT_GLOBALS.observe, contextObserverFunction], [COMPASS_FRAME_SPIKE_GLOBALS.observe, compassObserverFunction], [MISSION_MAP_FRAME_SPIKE_GLOBALS.observe, missionObserverFunction], diff --git a/src/main/certification/world-map-visibility-proof.ts b/src/main/certification/world-map-visibility-proof.ts new file mode 100644 index 000000000..97393c0b7 --- /dev/null +++ b/src/main/certification/world-map-visibility-proof.ts @@ -0,0 +1,27 @@ +/** + * Certifies the native World Map display flag at the start of its close path. + * The frame can survive the fade, so frame destruction cannot own visibility. + */ +import { readUleb } from "../core/wasm-binary.js"; +import { relocationAwareFingerprint } from "./semantic-proof.js"; +import { functionBody, signatureEvidence, type WasmEvidence } from "./wasm-evidence.js"; + +const DISPLAY_STATE_OPERANDS = [220, 232] as const; + +export function worldMapDisplayStateAddress(evidence: WasmEvidence): number | null { + const module = evidence.moduleView(); + // This owner creates World Map table slot 4152. Its close branch clears + // 0x80000 before starting the native 1 -> 0 opacity animation (function 6834). + const body = functionBody(module, 15_919); + const signature = signatureEvidence(module, 15_919); + if (body.byteLength !== 302 || signature === null + || signature.params.join() !== "i32,i32" || signature.results.length !== 0 + || relocationAwareFingerprint(body, DISPLAY_STATE_OPERANDS.map((start) => ({ + start, end: start + 5, role: "world-map.display-state", addressClass: "mutable-static", + }))) !== "23f59ceb29627fbbe1cddf258d6f1051ebb6690a52f7519603d0ec659890ed16") return null; + const addresses = DISPLAY_STATE_OPERANDS.map((offset) => readUleb(body, { offset })); + const address = addresses[0]; + return address !== undefined && address > 0 && address <= 0x7fff_fffc + && address % 4 === 0 && addresses.every((value) => value === address) + ? address : null; +} diff --git a/src/renderer/cartography-spike/frame-observer.ts b/src/renderer/cartography-spike/frame-observer.ts index de1df4b19..0c0a3ed5c 100644 --- a/src/renderer/cartography-spike/frame-observer.ts +++ b/src/renderer/cartography-spike/frame-observer.ts @@ -189,10 +189,13 @@ export function createCompassFrameSpikeReader( export function createWorldMapFrameSpikeReader( exports: WebAssembly.Exports, ): WorldMapFrameSpikeController | null { + const observe = exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe]; + if (typeof observe !== "function") return null; if (!WORLD_MAP_FRAME_SPIKE_SCALARS.every( (name) => exports[name] instanceof WebAssembly.Global, )) return null; const diagnostics = (): WorldMapFrameSpikeDiagnostic => { + observe(); return Object.freeze({ status: numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.status), sequence: numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.sequence), @@ -210,6 +213,9 @@ export function createWorldMapFrameSpikeReader( return Object.freeze({ diagnostics, snapshot() { + // The last complete projection may outlive the native close animation. + // Refresh visibility independently of projection events on every read. + try { observe(); } catch { return null; } const firstSequence = numberGlobal(exports, WORLD_MAP_FRAME_SPIKE_GLOBALS.sequence); const values = Object.fromEntries(WORLD_MAP_FRAME_SPIKE_SCALARS.map( (name) => [name, numberGlobal(exports, name)], diff --git a/src/shared/cartography-spike.ts b/src/shared/cartography-spike.ts index be758fc72..432923df5 100644 --- a/src/shared/cartography-spike.ts +++ b/src/shared/cartography-spike.ts @@ -131,6 +131,7 @@ export const MISSION_MAP_PROJECTION_SPIKE_SCALARS = Object.freeze([ ]); export const WORLD_MAP_FRAME_SPIKE_GLOBALS = Object.freeze({ + observe: "gwonmac_world_map_frame_spike_observe", status: "gwonmac_world_map_frame_spike_status", sequence: "gwonmac_world_map_frame_spike_sequence", generation: "gwonmac_world_map_frame_spike_generation", diff --git a/tests/client-artifact/world-map-visibility-proof.test.ts b/tests/client-artifact/world-map-visibility-proof.test.ts new file mode 100644 index 000000000..fdd057cbf --- /dev/null +++ b/tests/client-artifact/world-map-visibility-proof.test.ts @@ -0,0 +1,37 @@ +/** Certifies close-before-fade visibility and refuses a changed native owner. */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { worldMapDisplayStateAddress } from "../../src/main/certification/world-map-visibility-proof.js"; +import { wasmEvidence } from "../../src/main/certification/wasm-evidence.js"; +import { concat, encodeCode, encodeSection, paddedIndex, parseCode, sectionById, splitSections, WASM_HEADER } from "../../src/main/core/wasm-binary.js"; + +const artifact = process.env.GW_CLIENT_WASM; + +test("proves the World Map display flag and rejects mismatched reads, writes, and close behavior", async () => { + assert.ok(artifact, "GW_CLIENT_WASM must name the exact official artifact"); + const input = new Uint8Array(await readFile(artifact)); + const evidence = wasmEvidence(input); + assert.ok(evidence); + const address = worldMapDisplayStateAddress(evidence); + assert.ok(address); + const module = evidence.moduleView(); + const changed = (edit: (body: Uint8Array) => void) => { + const sections = splitSections(input); + const bodies = parseCode(sectionById(sections, 10)); + edit(bodies[15_919 - module.functionImportCount]!); + const bytes = concat(WASM_HEADER, ...sections.map(section => encodeSection( + section.id === 10 ? { id: 10, body: encodeCode(bodies) } : section))); + const changedEvidence = wasmEvidence(bytes); + assert.ok(changedEvidence); + return worldMapDisplayStateAddress(changedEvidence); + }; + assert.equal(changed(body => body.set(paddedIndex(address + 16), 220)), null); + assert.equal(changed(body => body.set(paddedIndex(address + 16), 232)), null); + assert.equal(changed(body => { + body.set(paddedIndex(address + 16), 220); + body.set(paddedIndex(address + 16), 232); + }), address + 16, "both state operands can relocate together"); + assert.equal(changed(body => { body[228] = body[228]! ^ 1; }), null, + "a changed close mask must not certify"); +}); diff --git a/tests/unit/cartography-visibility-transform.test.ts b/tests/unit/cartography-visibility-transform.test.ts new file mode 100644 index 000000000..0d859bbd6 --- /dev/null +++ b/tests/unit/cartography-visibility-transform.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { concat, encodeCode, encodeSection, uleb, WASM_HEADER } from "../../src/main/core/wasm-binary.js"; +import { encodeName, encodeTypes, nativeFrameObserver, worldMapVisibilityObserver } from "../../src/main/certification/cartography-transform-internals.js"; + +function observer(body: Uint8Array, types: readonly ("i32" | "f32")[]) { + const section = (id: number, body: Uint8Array) => encodeSection({ id, body }); + const bytes = concat(WASM_HEADER, + section(1, encodeTypes([{ params: [], results: [] }])), + section(3, Uint8Array.of(1, 0)), + section(5, Uint8Array.of(1, 0, 1)), + section(6, concat(uleb(types.length), ...types.map(type => type === "i32" + ? Uint8Array.of(0x7f, 1, 0x41, 0, 0x0b) : Uint8Array.of(0x7d, 1, 0x43, 0, 0, 0, 0, 0x0b)))), + section(7, concat(uleb(types.length + 2), encodeName("observe"), Uint8Array.of(0, 0), + encodeName("memory"), Uint8Array.of(2, 0), + ...types.map((_, i) => concat(encodeName(`g${i}`), Uint8Array.of(3), uleb(i))))), + section(10, encodeCode([body]))); + const { exports } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(bytes))); + const memory = exports.memory; + const run = exports.observe; + assert.ok(memory instanceof WebAssembly.Memory && typeof run === "function"); + const global = (i: number) => { const value = exports[`g${i}`]; assert.ok(value instanceof WebAssembly.Global); return value; }; + return { run, global, memory: new DataView(memory.buffer) }; +} + +test("native World Map closing withdraws a retained projection without a map event", () => { + const probe = observer(worldMapVisibilityObserver({ visible: 0, sequence: 1 }, 64), ["i32", "i32"]); + probe.global(0).value = 1; + probe.global(1).value = 9; + probe.memory.setUint32(64, 0x80004, true); + probe.run(); + assert.equal(probe.global(0).value, 1); + assert.equal(probe.global(1).value, 9); + probe.memory.setUint32(64, 4, true); + probe.run(); + assert.equal(probe.global(0).value, 0); + assert.equal(probe.global(1).value, 10); + probe.run(); + assert.equal(probe.global(1).value, 10, "unchanged hidden state is not a new publication"); + probe.memory.setUint32(64, 0x80000, true); + probe.run(); + assert.equal(probe.global(0).value, 0, "opening cannot resurrect stale geometry"); +}); + +test("native map frames hide at destruction start, before removal from the frame array", () => { + const certificate = { frameArray: 4, frameCount: 8, frameBytes: 48, frameId: 0, frameHashId: 4, + frameState: 8, frameViewportWidth: 12, frameViewportHeight: 16, + frameScreenLeft: 20, frameScreenBottom: 24, frameScreenRight: 28, frameScreenTop: 32, labelHash: 77 }; + const globals = { status: 0, generation: 1, frameId: 2, visible: 3, + viewportWidth: 4, viewportHeight: 5, left: 6, bottom: 7, right: 8, top: 9 }; + const probe = observer(nativeFrameObserver(certificate, globals, 10), + ["i32", "i32", "i32", "i32", "f32", "f32", "f32", "f32", "f32", "f32", "i32"]); + probe.memory.setUint32(4, 64, true); + probe.memory.setUint32(8, 1, true); + probe.memory.setUint32(64, 128, true); + probe.memory.setUint32(128, 0, true); + probe.memory.setUint32(132, 77, true); + for (const [state, visible] of [[4, 1], [12, 0], [0x204, 0], [0, 0], [4, 1]]) { + probe.memory.setUint32(136, state!, true); + probe.run(); + assert.equal(probe.global(0).value, 1); + assert.equal(probe.global(3).value, visible); + } +}); diff --git a/tests/unit/compass-frame-spike-observer.test.ts b/tests/unit/compass-frame-spike-observer.test.ts index ed12ef4f5..9d4063c67 100644 --- a/tests/unit/compass-frame-spike-observer.test.ts +++ b/tests/unit/compass-frame-spike-observer.test.ts @@ -222,6 +222,7 @@ test("refuses incomplete, stale, and invalid Mission Map projection state", () = function worldMapExports(): WebAssembly.Exports { const values = [1, 12, 5, 200, 1, 1_920, 1_080, 0, 0, 1_920, 1_080, 0, 0, 0, 0, 8_192, 16_384]; return { + [WORLD_MAP_FRAME_SPIKE_GLOBALS.observe]: () => undefined, ...Object.fromEntries(WORLD_MAP_FRAME_SPIKE_SCALARS.map((name, index) => [ name, scalar(values[index]!, index < 5 || index === 11 ? "i32" : "f32"), @@ -253,3 +254,25 @@ test("reads the dedicated World Map context atomically", () => { invalid[WORLD_MAP_FRAME_SPIKE_GLOBALS.bottomRightX] = scalar(0, "f32"); assert.equal(createWorldMapFrameSpikeReader(invalid)?.snapshot(), null); }); + +test("refreshes World Map visibility even when no new projection event arrives", () => { + const exports = worldMapExports(); + const visible = exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.visible]; + assert.ok(visible instanceof WebAssembly.Global); + let showing = true; + exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe] = () => { if (!showing) visible.value = 0; }; + const reader = createWorldMapFrameSpikeReader(exports); + assert.ok(reader); + assert.ok(reader.snapshot()); + showing = false; + assert.equal(reader.snapshot(), null); + assert.equal(reader.diagnostics().visible, 0); + showing = true; + assert.equal(reader.snapshot(), null, "reopening must wait for a complete new projection"); + visible.value = 1; + assert.ok(reader.snapshot()); + exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe] = () => { throw new Error("unavailable"); }; + assert.equal(createWorldMapFrameSpikeReader(exports)?.snapshot(), null); + delete exports[WORLD_MAP_FRAME_SPIKE_GLOBALS.observe]; + assert.equal(createWorldMapFrameSpikeReader(exports), null); +});